Skip to content

Flink: Support Lookup Join using full in-memory lookup cache - #18144

Open
Guosmilesmile wants to merge 4 commits into
apache:mainfrom
Guosmilesmile:lookup_join_heap
Open

Guosmilesmile wants to merge 4 commits into
apache:mainfrom
Guosmilesmile:lookup_join_heap

Conversation

@Guosmilesmile

@Guosmilesmile Guosmilesmile commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

This PR adds lookup join support for the Iceberg Flink table source, using a full in-memory lookup cache.

Part of #18142

The implementation enables Iceberg tables to be used as temporal lookup join dimensions in Flink SQL.

Supported Features

  • Lookup join against Iceberg table source

    • Supports Flink SQL temporal lookup join syntax:
      LEFT JOIN iceberg_catalog.`db`.`dim_table`
        FOR SYSTEM_TIME AS OF o.proc_time AS u
      ON o.user_id = u.user_id
  • Pushed-down filter support

    • Existing source filters are reused when loading the cache.
    • Join conditions such as:
      ON o.user_id = u.user_id AND u.city = 'beijing'
      are applied together with the lookup key condition.
  • Full in-memory lookup cache

    • The whole projected dimension table is loaded into a cache on the TaskManager heap, and every lookup is served from it, never falling back to the table.
    • A dimension key may match multiple rows; all matching rows are returned and joined.
    • Only lookup.cache=FULL is accepted; NONE and PARTIAL are rejected, because an Iceberg table cannot be point-looked-up effectively.
  • Configurable load strategy

    • lookup.full-cache.eager-load selects when the cache is loaded:
      • false (default): on the first lookup. Simple, but that probe row is blocked for the duration of the load.
      • true: in open(), so no probe row is blocked, and a load that fails fails the job at startup instead of mid-stream.
  • Metrics

    • Reported under the icebergLookupCache group: cacheHit and cacheMiss counters, and snapshotId and cachedRows gauges.

How to Use

Basic lookup join

SELECT o.order_id, o.user_id, u.name, u.city
FROM orders AS o
LEFT JOIN iceberg_catalog.`db`.`users` FOR SYSTEM_TIME AS OF o.proc_time AS u
ON o.user_id = u.user_id;

Load the cache when the lookup function opens

SELECT o.order_id, o.user_id, u.name, u.city
FROM orders AS o
LEFT JOIN iceberg_catalog.`db`.`users`
/*+ OPTIONS('lookup.full-cache.eager-load' = 'true') */
FOR SYSTEM_TIME AS OF o.proc_time AS u
ON o.user_id = u.user_id;

or

CREATE TABLE dim_users (
  user_id BIGINT,
  name STRING,
  city STRING
) WITH (
  'connector' = 'iceberg',
  'catalog-name' = 'iceberg_catalog',
  'catalog-type' = 'hadoop',
  'warehouse' = '/path/to/warehouse',
  'catalog-database' = 'db',
  'catalog-table' = 'users',

  'lookup.full-cache.eager-load' = 'true'
);

Options

Option Required Default Description
lookup.full-cache.eager-load No true Whether to load the cache in open(), instead of on the first lookup.

Both can be set per join with an OPTIONS hint, or in the table DDL WITH clause.

Notes

  • Memory only. The cache lives on the TaskManager heap, so this targets dimension tables that fit comfortably there. A disk-backed backend is left for a follow-up — keeping it out of this PR avoids adding native code to the runtime jar and any conflict with the RocksDB copy Flink already ships for its RocksDB state backend.
  • Loaded once, no refresh. The cache is loaded when the lookup function starts serving and is then kept for the lifetime of the job; there is no background reload. The dimension table should be populated before the join starts.

@Guosmilesmile

Guosmilesmile commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

For snapshot pinning, I was thinking we could resolve the snapshot at job submission time and pass the snapshot ID to every TaskManager, so that all caches use the same snapshot. But there is a problem with this approach.

The problem is failover. Flink doesn't re-run the planner when a task restarts, so the pinned snapshot ID would remain unchanged. If that snapshot has expired by then, the cache load would fail with Cannot find snapshot with ID ... after the restart, and retries would keep failing until the job is resubmitted.

Periodic refresh doesn't help here, because the failure happens during the initial cache load. We also don't have a way to persist and update the snapshot ID from the connector side, since a lookup join doesn't have checkpointed state.

So while pinning the snapshot would ensure consistency across subtasks, it introduces a failover problem.

Additionally, dimension tables do not change frequently.

For this PR, I therefore use the latest snapshot at the moment and just added logging and a metric to report the snapshot ID being used.If you have a better idea, we'd be happy to take a look.

@swapna267

Copy link
Copy Markdown
Contributor

why not use ReadOptions and pass when creating the IcebergFullCachingLookupFunction, which will keep it consistent across task restarts or job restarts.

At table creation time,

CREATE TABLE dim_users (
  user_id BIGINT,
  name STRING,
  city STRING
) WITH (
  'connector' = 'iceberg',
  'catalog-name' = 'iceberg_catalog',
  'catalog-type' = 'hadoop',
  'warehouse' = '/path/to/warehouse',
  'catalog-database' = 'db',
  'catalog-table' = 'users',

  'lookup.full-cache.eager-load' = 'true',
  'snapshot-id' = '121334'
);

OR per query

SELECT o.order_id, o.user_id, u.name, u.city
FROM orders AS o
LEFT JOIN iceberg_catalog.`db`.`users`
/*+ OPTIONS(
     'lookup.full-cache.eager-load' = 'true',
     'snapshot-id' = '121334'
   ) */
FOR SYSTEM_TIME AS OF o.proc_time AS u
ON o.user_id = u.user_id;

There could be other strategies to simplify , instead of user specifying exact snapshot id also like latest or specific tag or as-of-timestamp

@Guosmilesmile

Copy link
Copy Markdown
Contributor Author

@swapna267 Showing a specific snapshot ID is one approach, but it doesn't solve the problem of that snapshot expiring. On top of that, most users won't bother specifying a particular snapshot anyway.

@swapna267

Copy link
Copy Markdown
Contributor

Yes it doesn't need to be particular snapshot Id. Instead it could be Latest_Snapshot or a particular Branch/Tag. I was referring to resolving it on Job Submission time instead of on TaskManagers. But i just realized you already mentioned that option.

Instead of being inconsistent with lookup result across TaskManagers, prefer to have all TM's load from same Snapshot Id. And fail loudly, incase of an expired snapshot or a Tag.

@Guosmilesmile

Copy link
Copy Markdown
Contributor Author

@swapna267 I agree that resolving the snapshot ID at Job Submission time works well when the cache is not refreshed, with an explicit failure if the snapshot has expired.

However, with periodic refresh, a long-running job may encounter a failover after the original snapshot has expired. Resolving the snapshot ID at Job Submission would require manual intervention to restart the job, which doesn't seem ideal for the periodic refresh use case.

I also noticed that other connectors supporting lookup joins generally establish their connections independently on each TaskManager.

So I don't think resolving the snapshot ID at the Job Submission level is a good fit for this use case.

This is also why I'm still leaning toward using the latest snapshot rather than pinning to a specific snapshot.

@Guosmilesmile

Copy link
Copy Markdown
Contributor Author

@pvary @mxm @talatuyarer If you get a chance, please take a look and let me know what you think. I'd really appreciate it.

@swapna267

Copy link
Copy Markdown
Contributor

Thanks @Guosmilesmile . Yes I agree, this wouldn't make sense for Periodic Refresh .
With periodic refresh, falling back to Latest makes sense.

As this PR's scope was limited to one time load with no periodic refresh, i was recommending that.

Comment thread docs/docs/flink-queries.md Outdated
Comment thread docs/docs/flink-queries.md Outdated
| Option | Default | Description |
| ---------------------------- |---------|-----------------------------------------------------------------------------------------------------------------------------|
| lookup.cache | | Only `FULL` is accepted; `NONE` and `PARTIAL` are rejected, because an Iceberg table cannot be point-looked-up effectively. |
| lookup.full-cache.eager-load | true | Whether to load the full cache when the lookup function is opened, instead of on the first lookup. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we usually do hiearchical. So maybe lookup.cache.full.eager-load, or since we don't have full, we can just say lookup.cache.eager-load?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used the lookup.full-cache. prefix because I’d like the refresh-related options we add later to be consistent with the existing Flink options.

https://github.com/apache/flink/blob/master/flink-table/flink-table-common/src/main/java/org/apache/flink/table/connector/source/lookup/LookupOptions.java

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense

import org.apache.flink.table.data.RowData;

@Internal
interface IcebergLookupCache extends Closeable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we introduce this only once we have more cache implementations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense, at first I think we will add other back end in the future. Remove it now.


@Override
public void add(RowData key, RowData row) {
cache.computeIfAbsent(key, k -> Lists.newLinkedList()).add(row);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: why linked list?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was my mistake. I misjudged the scale of the data here.

RowType projectedRowType = (RowType) projected.toPhysicalRowDataType().getLogicalType();
List<Expression> pushedFilters = filters == null ? ImmutableList.of() : filters;

Configuration lookupConf = Configuration.fromMap(properties);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not FlinkConfParser?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to consistently use FlinkConfParser.

lookupFunction = newLookupFunction();
lookupFunction.open(new FunctionContext(null));

Collection<RowData> rows = lookupFunction.lookup(keyRow(1L));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add test with multicolumn key

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add it now.

assertThat(rows).singleElement().satisfies(row -> assertRow(row, 1L, "alice", "A"));

// Served from the cache, not re-read from the table.
assertThat(lookupFunction.lookup(keyRow(1L))).isSameAs(rows);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should rely on the cache returning the exact rows is a good idea. We might change the implementation which could return a copy of the rows, or something.

Could we just assert on the contents, or on some metrics?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, add a method assert on the contents.

Comment thread docs/docs/flink-queries.md Outdated

| Option | Default | Description |
| ---------------------------- |---------|-----------------------------------------------------------------------------------------------------------------------------|
| lookup.full-cache.eager-load | true | Whether to load the full cache when the lookup function is opened, instead of on the first lookup. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use `-s, and format the table

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we could add it to docs/docs/flink-configuration.md,?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add it now.

Comment thread docs/docs/flink-queries.md Outdated

Iceberg implements lookup join with a full cache: the whole projected dimension table is loaded into the cache, and every lookup is served from it without falling back to the table. The full cache is held in memory on the TaskManager heap, so lookup join targets dimension tables that fit comfortably there.

The cache is loaded by default when the lookup function is opened. Set lookup.full-cache.eager-load to false to load it on the first lookup instead, which blocks the data flow until the cache is fully loaded.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reword to make it clear that this is a decision between where/when to block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

* A full caching lookup function: the whole projected Iceberg dimension table is loaded into an
* in-memory cache on the first lookup, and every lookup is served from that cache.
*/
public class IcebergFullCachingLookupFunction extends LookupFunction {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this to be public?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we call this class in other package, I add Internal for this class now.

Comment on lines +75 to +76
private transient Counter cacheHitCounter;
private transient Counter cacheMissCounter;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need these with only full cache supported?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept only lookupMiss (probe rows whose key is not in the cached dimension table) and dropped cacheHit, which for a full cache simply equals the number of lookups.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have stat for the number of lookups?

Comment on lines +125 to +132
this.flinkConfParser = new FlinkConfParser(properties, readableConfig);
this.caseSensitive =
flinkConfParser
.booleanConf()
.option(FlinkReadOptions.CASE_SENSITIVE)
.flinkConfig(FlinkReadOptions.CASE_SENSITIVE_OPTION)
.defaultValue(FlinkReadOptions.CASE_SENSITIVE_OPTION.defaultValue())
.parse();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe put into the getLookupRuntimeProvider?

Comment thread docs/docs/flink-queries.md Outdated

The cache is loaded by default when the lookup function is opened. Set lookup.full-cache.eager-load to false to load it on the first lookup instead, which blocks the data flow until the cache is fully loaded.

There is no background refresh: the cache keeps the data it was loaded with for the lifetime of the job, so the dimension table should be populated before the join starts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention that the cache could be out of sync between the tasks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix it now.

options.add(FlinkCreateTableOptions.CATALOG_TABLE);
options.add(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK);
options.add(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL);
options.add(LookupOptions.CACHE_TYPE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to remove this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not strictly needed for behavior, but I kept it as a declaration. optionalOptions() is the only place that lists the supported options, and once the factory starts using TableFactoryHelper.validate(), an undeclared lookup.cache would fail. Happy to drop it if you'd rather.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove it if we don't use it now. We can always add back later


LOG.info(
"IcebergFullCachingLookupFunction loading started, snapshot={}, committedAt={}, projected fields={}, pushedFilters={}",
snapshotId == IcebergLookupReader.CURRENT_SNAPSHOT ? "none" : snapshotId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we use null instead of a constant?

Comment on lines +114 to +124
Schema tableSchema = table.schema();
List<String> projectedColumns = projectedRowType.getFieldNames();
Types.NestedField[] projectedFields = new Types.NestedField[projectedColumns.size()];
for (int i = 0; i < projectedColumns.size(); i++) {
String column = projectedColumns.get(i);
Types.NestedField field = tableSchema.findField(column);
Preconditions.checkArgument(field != null, "Cannot find column '%s' in table schema", column);
projectedFields[i] = field;
}

Schema icebergProjection = new Schema(projectedFields);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Schema tableSchema = table.schema();
List<String> projectedColumns = projectedRowType.getFieldNames();
Types.NestedField[] projectedFields = new Types.NestedField[projectedColumns.size()];
for (int i = 0; i < projectedColumns.size(); i++) {
String column = projectedColumns.get(i);
Types.NestedField field = tableSchema.findField(column);
Preconditions.checkArgument(field != null, "Cannot find column '%s' in table schema", column);
projectedFields[i] = field;
}
Schema icebergProjection = new Schema(projectedFields);
Schema icebergProjection =
FlinkSchemaUtil.convert(table.schema(), FlinkSchemaUtil.toResolvedSchema(projectedRowType));

Comment on lines +163 to +165
assertThat(lookupFunction.lookup(keyRow(null, "A")))
.singleElement()
.satisfies(row -> assertRow(row, null, "nobody", "A"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this required from the Flink side?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can we reach this with SQL?

import org.junit.jupiter.api.Test;

/** Tests SQL lookup join with Iceberg table source. */
class TestIcebergLookupJoinSql extends TestSqlBase {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we add an eager load test?

options in the DDL, or per query with the `OPTIONS` hint:

```sql
SELECT o.order_id, u.name

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SET table.dynamic-table-options.enabled=true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe remove the example from here and just link the other page?

Comment on lines +116 to +121
The lookup options are:

| Option | Default | Description |
| ------------------------------ | ------- | -------------------------------------------------------------------------------------------------- |
| `lookup.full-cache.eager-load` | `true` | Whether to load the full cache when the lookup function is opened, instead of on the first lookup. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just link the config here.

The main goal is to write everything only once

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants